Handler: submit the line it types, and ask the agent before answering for it - #71
Merged
Merged
Conversation
… for it A PTY auto-reply was one write of text + CR. A coding-agent TUI tokenizes a read as a whole — Claude Code emits a control character as its own key event only while the read is under 64 characters — so every reply at or above that landed in the composer as literal text and was never sent. The engine banked the transition anyway, so the session rested at watching with the prompt stranded and no turn-end coming. Submitting now goes through TerminalSession.submit: the line, a gap, then the CR in a read of its own, serialized per terminal so a keystroke cannot land inside it. A bare slash verb is padded with one space, which clears the suggestion list that would otherwise route Enter to accept rather than submit. The same seam covers terminal:input, so an escalation chip — up to 400 characters — no longer types without submitting while the card latches disabled and both sides drop the row. onUserReply gated on isSubmitKeystroke. It reset the runaway guard on every inbound byte, and once an agent enables mouse tracking every pointer event is one of those, so moving the mouse bought an unbounded auto-reply budget. The CR/LF test it used also matched alt+enter and a paste's interior newlines, retiring escalations while the agent stayed blocked. The predicates move to keystrokes.ts, a leaf both agent-core and the handler can import. buildDecidePrompt gains the move it was missing: ask the agent when information is missing, rather than assuming and writing a recipe. The agent holds the live session and the working tree; the judge reads a bounded excerpt. Ordered after the escalate rule so a question cannot divert what belongs to the human. replyShape flattens the slash-command argument tail the way it already flattened reply, and names the field it actually refused — the reason is fed verbatim to the one retry, so it was teaching the judge to edit the field it got right. The destructive floor learns the outward moves: gh pr merge/close, gh release/repo delete, forced branch delete, git tag -d, npm publish. Advisory, never HARD — merging the pull request is routinely the backlog's whole point. Nothing snapshots them, so they report that no undo exists instead of passing in silence.
…tself Review of the previous commit. The floor additions had two defects that matter: `gh pr (merge|close)` and `gh (release|repo) delete` were each one regex over two operations, and §5.4 keys an authorization lift on the pattern SOURCE — so "close the stale PRs" granted every merge, and an authorized release delete authorized deleting the repository. Split one operation per pattern. PR_ANCHOR accepted a bare `#42`, but GitHub numbers issues and pull requests in one series, so "closes #42 once the fix lands" — a backlog line about an issue — granted a session-wide gh pr close. The bare arm is gone; "PR #42" still anchors. The merge alias also matched "fix the merge conflicts on PR #12", which asks for the opposite of a merge. The git branch flags had no option-token boundary, so `-perf` in a branch name read as a force and flagged the safe `git branch -d`, while the grouped spellings `-fd` and `-Dr` matched nothing. git tag scanned past quotes and separators, so `-m "fix -d flag"` read as a tag deletion. Publish flagged --dry-run and knew only npm, not the pnpm/yarn/bun this repo actually uses. The capability responder wrote straight to the PTY, the one writer that could land between an injected line and its deferred CR — the interleave the queue exists to prevent. It goes through the queue, which is a pass-through whenever no submit is in flight. The queue opened a gap before the CR but not after, so the next write joined the CR's read and robbed it of its key event one step later than the bug this fixes. padBareVerb treated an absolute path as a slash verb. hasTypedContent counted the terminal's own mouse and focus reports as typed content, and typedSessions outlives the frame that set it — so one pointer move made the next bare Enter open a turn no stop hook would close, on the same premise the previous commit acted on for onUserReply. The judge's onTimeout fired on one of the three legs that spend the budget, staying silent on an exhausted budget and a hung retry — the two that most need naming. The wrap-up push put the expiring undo offer last, behind an unbounded summary, in a string OS surfaces truncate.
bharathm03
added a commit
that referenced
this pull request
Sep 1, 2026
Six conflicts, all where #71 improved the same code HF-3 deleted and HF-6 replaced. Two of them were live risks rather than textual noise, because the auto-merge would have kept our side and silently reverted a shipped fix: The push named what a guard refused; our composer only counted it. #71's point stands and is now the composer's: a count reads the same whether the guard stopped something trivial or the one thing the session existed to do, and the push is the only channel reaching a phone whose app was not running when the activity rows went out. `blockedClause` renders the record's reasons, capped at two for the lock screen while the card keeps three. The undo clause belonged AHEAD of the reports; ours put it last. OS surfaces truncate the tail, and of the two only the undo expires — the reports keep on the wrap-up card, while the offer to undo is gone once the user stops looking for it. Restored, and pinned by a test, which nothing did before. Kept from #71 otherwise: the activity-log rotation and its three tests, and the injective `JSON.stringify([tier, matched])` Set key that replaced the NUL separator. Its rotation comment claimed the jsonl was the only durable copy of what a wrap-up describes — true when written, and no longer, so it now says what is still true: those rows are the only record of the session decision by decision. Thirteen `notifyOnly: false` arguments arrived in tests #71 added. They merged clean, so nothing flagged them against a field HF-3 removed; stripped by hand.
bharathm03
added a commit
that referenced
this pull request
Sep 2, 2026
* Update dart_terminal viewport fix (#65) Pin the terminal packages to the squash merge of antgrid-ai/dart_terminal#10 so Antgrid receives the origin-safe scrollbar synchronization and selection auto-scroll fix. * Handler: a status frame answers for exactly one append (#64) * A frame that answers one sentence is never spent on the next A partial cap hit appends, records instruction_dropped AND emits a status snapshot whose backlog that same sentence just moved. Only the amendment path was credited for that, so the cap hit's frame was left to answer for the next sentence in the queue — retiring its row and lifting the edit lock while its extraction was still running. A survivor is now always credited; crediting a frame the bridge never sends costs one re-baseline, which is what the survivor was doing anyway. Alongside it, four places where one rule had two homes: oneLine is defined once in the import-free leaf and re-exported, hostsIn is built on destinationsIn so "the subset of" is true by construction, clipQuote escapes through previewForUser like every other user-facing preview, and a shared clip() stops a cap landing between a surrogate pair. The drawer's waits-on lines resolve against one map per build rather than re-walking the backlog per link. * The goal seeded at arm answers for its own append A goal is extracted on the same per-terminal chain instructions queue on, and lands ahead of them. Its items moved the backlog count that was the only evidence a sentence had, so a preset tapped while the goal was still running was retired by the goal's own append — taking the "sending" row away, lifting the debounce, and lifting the drawer's edit lock while the preset's extraction had not started. The next wholesale edit then went out built from a list missing the items about to arrive. One client reaches this: arm from the new-session prompt, tap a chip before the goal comes back. The mark is set on exactly the condition the bridge queues that pass on — a goal with words in it and no backlog beside it — spent by the first frame that actually moved, and dropped once the backlog is non-empty, since a goal is only ever extracted into an empty one and a mark left standing would swallow the frame the user's own sentence raised. * feat: start an isolated session's agent alongside worktree.setup (#66) * fix(app): show the provisioning run instead of calling a queued session stopped While worktree.setup runs the bridge holds the session's start, so the entry reports running:false with setup.pendingStart set. TerminalScreen branched on running alone and rendered "Session stopped" over a Start button whose press only re-entered the same gate — a dead control directly under a banner saying the workspace was being prepared. The pane now branches on sessionStartQueued to a provisioning state whose body is the setup transcript itself: during the run that PTY is the only live output the session has, and it was collapsed behind a chevron above an empty pane. It carries the two verbs that end the wait — Start agent now (skip) and Cancel setup, the latter a bridge verb no surface had offered since it shipped. Renames the banner's Skip to Start agent now: nothing about the run is skipped, the queued agent is released and the install keeps going. The banner drops its tail line and log disclosure while a start is queued so the transcript is not mounted twice, both derived from the same wire field. * feat(bridge): let a project launch its agent alongside worktree.setup Adds worktree.setup.startAgent: afterSetup | immediate. The default keeps today's behaviour — the session:start is queued and fired when the run settles — while immediate lets the agent come up with the first step, for a project whose setup is a cheap .env copy rather than a cold install. The mechanism is one seeded field: beginSetup births the run with gateReleased true, which is the same state a Skip produces, so setupGate declines to report it and start() falls through to the spawn with no branch of its own. Skip stays idempotent, cancel and rerun are untouched, and firePendingStart finds nothing queued. checkoutDeclaresSetup widens to checkoutSetupPolicy, returning declares plus startAgent; an unreadable config now fails closed on both axes rather than one. The rerun path re-reads the policy instead of remembering the create-time answer — a rerun is exactly when the checkout's branch has changed it. The services: deferral stays tied to declares alone: bun run dev against an unprovisioned node_modules fails with nobody watching, unlike an agent. * feat(app): warn when the agent is live in an unprovisioned tree Under startAgent: immediate — or after a hand-pressed release — the agent is running in a checkout that has no node_modules yet, which is a different claim from a neutral progress line. The banner is the only surface that can make it, so it takes the warning tone, says 'Workspace still installing' rather than promising a wait, and offers 'Cancel setup' in place of a release that has nothing left to release. Derived from the two live facts (setup running AND session running) rather than a mode flag, so a config-set immediate and a hand-pressed Start agent now reach the same warning. * feat: make the provisioning wait legible "2 of 5" is actively misleading on a real setup block: step 1 is a 10ms copy: and steps 2-5 are the minutes. The pane now carries the ledger — done, current, still to come — and the banner an elapsed readout beside the rule, which is what separates a slow step from a hung one when a bun install prints nothing for four minutes. The step names are the one new wire field: CheckoutSetupProgress carries them on every report of a run rather than once, SetupRuntime retains them like terminalId, and they are optional in both directions so an older app ignores the key and a state recovered from disk (which knows how many steps ran but not what they were called) renders no ledger at all rather than a column of blanks. The elapsed reading comes off the bridge's clock, which for a remote machine is not ours; a negative result is the one shape of skew we can detect and it is answered by saying nothing. * chore: start this repo's isolated agents alongside setup Five steps, minutes on a cold worktree, and the agent is useful for most of that — reading, planning and searching all work in a tree that is only checked out. Revert this one line to put the wait back. * fix: close the review's findings on the setup-start work The load-bearing one is a bug: a rerun under startAgent: immediate re-arms the previous run's prompt AND opens the gate, and nothing fires a start behind an open gate — firePendingStart runs only when a run settles. The policy was therefore silently ignored on the one path that queues a start of its own, leaving the user to press Start agent now by hand on a project configured never to wait. The rerun test that was supposed to cover this passed for the wrong reason: settleSetup awaits startDeferredServices, so the synchronous stop() beat firePendingStart and lastQueuedPrompt was never banked — the rerun took the no-requeue path. It now waits for the queued start, and fails without the fix. App side: a chat session mounts AgentTranscriptView where the provisioning pane would be, so standing the banner's log and tail down on a queued start left a four-minute install with no output anywhere; the suppression now keys on the pane actually being there. The banner also stood down its action, since two Start agent now buttons 100px apart race for a run only one can end. The pane is keyed by session so an in-flight verb and its refusal cannot land on whichever session is on screen when the reply arrives, a null registration is named rather than dropped, and an expansion the chevron no longer offers is cleared instead of masked — masking alone unfolded the log by itself the moment the gate released. Also folded two duplicated helpers back into their existing versions: listEquals for the step-name comparison, and the transcript's formatDuration so the two live elapsed readouts on one screen spell the same seconds the same way. * A dropped instruction owes the app the frame its record spends (#68) Regression from #64. Retiring a sentence off an activity record now always credits the terminal's next status frame, but two bridge paths recorded an instruction row and returned without emitting one — an amend-only drop, and a cap hit with no room at all. The credit was then spent by the NEXT sentence's own append, so that sentence's row stood forever and held the backlog edit lock, which under a full backlog is the only way to free room. Fixed on the bridge, because no app-side rule can work: nothing in an instruction_dropped record says whether a frame is coming. Every path that records an instruction row now emits a snapshot straight after it, making the app's blanket rule a real invariant. An unchanged snapshot answers for nothing and costs one re-baseline. The arm-time goal mark also now mirrors the bridge's goalChanged gate, so re-arming with an unchanged goal no longer sets a mark nothing will satisfy. A goal the bridge rehydrates from its own disk record still cannot be predicted from the app. * Handler: confirm the undo that leaves this machine, band escalations, answer the judge question before arming (#69) * The one undo that leaves this machine asks first Three of the four snapshot actions undo locally; undoing a force push writes to a shared remote. The offer is a scrolling list row whose entire body is the tap target, newest first, so the row most likely to sit under a thumb was also the one that could overwrite a ref for everyone on it. Confirmed for force_push alone. The other three keep the one-tap prevention §5.2 buys back, and the dialog promises no recovery: the bridge pins the remote tip before overwriting it only when the ref still exists there. * An escalation that stopped the agent sorts above one that merely waited urgency reached exactly one consumer, the OS notification title, and that path returns early while you are viewing the session it names. Inside the app the field was invisible: high and normal rendered identically, and oldest-first sorting filed a fresh urgent row last. high is not judge opinion. The engine mints it with no judge call at all for a blocking prompt, so it means the agent is stopped right now. Banded ordering with age deciding within a band, applied on the live push too, and marked in the meta column the three escalation row shapes share. * Escalate-only is answerable before arming, not after walking away The catalog already carried judgeCapable and the app already resolved the agent the way the bridge does, so the pre-arm half of the coverage answer was one field away. Until now it surfaced only as an ESCALATE ONLY chip, found on returning to a session that had woken you for everything. The prediction is exact rather than approximate because nothing writes a per-session judge override yet: observabilityFor falls back to the session's own tool, which is what the catalog describes. A judge picker inherits the job of keeping that true. Withheld from the unwatchable arm, which already carries the stronger fact. * Corrections to the three fixes above latestEscalationId read escalations.last, which the new banding turned into the newest NORMAL escalation — never an urgent one, i.e. never the row a caller asking for the latest wants. Folded on at instead. No live caller today, so this was a trap rather than a bug. The undo tap started async work from a void callback with a bare unawaited, which app/CLAUDE.md forbids outright: a throw past the dialog reaches PlatformDispatcher.onError as a FATAL with no in-app frames. Uses detached now. The urgency test is a shared escalationMeta closure rather than three hand-written copies, which is what the adjacent comment already claimed. compareEscalations' doc asserted high is engine-minted only. It is not: escalate passes the judge's own notify.urgency through, so a judge-authored high sorts into the same band. The band is still right; the claim was not. Two bridge comments calling backlog.ts import-free were wrong the same way — it imports zod and ./evidence; what it actually has is a position below every consumer. * The shield answers the judge question every time it is asked The escalate-only caveat added to the arm explainer almost never rendered: the explainer is gated on FirstRunState.handlerArmedOnce, a once-EVER latch, while coverage is per-agent. A user whose first arm was a judge-capable agent never sees that dialog again and would meet an escalate-only one with no warning at all. The shield tooltip is the pre-arm surface that answers every time, and it already carried the observability half. handlerShieldTooltip is top-level for the reason handlerArmExplainerBody is — the precedence is testable without pumping the panel — and keeps the explainer's order: unwatchable outranks escalate-only, since a session reporting nothing makes its judge moot. * feat(app): markdown document viewer with heading outline, link routing, and mono inline code The file viewer rendered markdown through markdown_widget's defaults — GitHub's light-theme tokens on a dark ground — with no reading measure, no way to follow a link, and no sense of a document's structure. Adds three files. markdown_document_config.dart is the whole-document config: a document-scale heading ramp pinned at all six levels, dark-appropriate blockquote/hr/table tokens, mono tables that scroll internally rather than overrun the measure, list markers whose shape encodes nesting depth, task boxes drawn with AbIcons, a copy button on every fence, and a chip for a repo-relative image the viewer can open in its own image view. markdown_outline.dart is the heading rail: MarkdownWidget publishes its heading list and scroll index only into its TocController, so MarkdownTocController subclasses it to get both out, and the rail is built from AbListRow instead of the package's Material ListTile. markdown_link_target.dart classifies an href so a relative repo link opens in the viewer, a #anchor jumps in-document, and only http(s)/mailto leave the app. Also fixes inline code across the agent transcript. CodeNode.style resolves as codeConfig.style.merge(parentStyle), and merge lets the argument win every non-null field, so the paragraph's sans family overwrote the configured mono one and CodeConfig was inert — every backticked identifier, path and flag rendered byte-identical to the prose around it. markdownAntgridGenerator re-asserts the family after that merge, family only, and transcript/markdown_body.dart renders through it too. * fix(app): bump webview_all to 1.4.1 for the Windows exit crash (#70) 1.4.1 removes the `SetUp(messenger_, nullptr)` call from `~WindowsHostApi`. That call ran from a plugin registrar destruction callback, which FlutterWindowsEngine fires from Stop() after its own destructor has already nulled the messenger's engine pointer — so it dereferenced null inside FlutterDesktopMessengerSetCallback, whose only guard is an FML_DCHECK that is compiled out in release. The app never pinned the platform package directly, so this is a lock-only change: `webview_all: ^1.3.5` in pubspec.yaml already admits 1.4.1, and no dependency_override or fork was ever added. Verified the fix is in the bytes we resolve: the published archive's sha256 (d9b81f1…) matches both pub.dev and this lockfile, and that archive's `~WindowsHostApi` no longer makes the call. Upstream: abandoft/webview_all#37, fixed by abandoft/webview_all#38. Not yet confirmed at runtime in Antgrid. Four local configurations (profile/release × with/without a host, three launch-and-close runs each) exit cleanly even on the buggy 1.3.10, so no loose build reproduces the fault and a clean run proves nothing. The signature is an access violation escaping a window-proc callback (0xC0000005 paired with 0xC000041D at the same offset), which is plausibly swallowed outside the packaged app — so confirmation has to come from an MSIX build. Refs #63, whose third close condition (no Application Error event on exit) is still open. Claude-Session: https://claude.ai/code/session_01Y8Fukr3ARpsyhoxrSMcPcm * Handler: submit the line it types, and ask the agent before answering for it (#71) Two reports from a live two-session run, both confirmed against the logs. The injected reply never sent. A TUI tokenizes a PTY read as a whole, and Claude Code emits a control character as its own key event only while the read is under 64 characters — so `text\r` in one write inserted a literal newline into the composer and waited for a human Enter. Every submit now writes the line, waits out a gap, then writes the CR alone, through a per-terminal queue that keeps any other writer from joining that read. The queue is a synchronous pass-through whenever no submit is in flight, so nothing else on the terminal pays for it. A bare slash verb is padded: splitting the CR would otherwise leave the suggestion list armed, and the key that used to submit would accept a completion instead. Handler answered for the agent. It holds less context and fewer tools than the model it supervises, so a confident guess reads as fact and costs the agent a correction it has no reason to make. It asks now, and decides from what comes back. Seven further findings from the log dig, each adversarially validated: the runaway guard reset on keystrokes that submitted nothing; the judge's timeout was silent on two of the three legs that spend the budget; the activity feed reported a blocked action with prose about the pause rather than the text a guard refused; the destructive floor missed several outward-moving operations, and the wrap-up push buried an expiring undo offer behind an unbounded summary. Floor patterns are one operation each. Section 5.4 keys an authorization lift on the pattern source, so an alternation over two operations lets a lift on either grant both. * A terminal resize the PTY never got is one the app must keep offering (#72) The driver re-sends terminal:resize only when its computed grid differs from _lastSentCols/_lastSentRows -- what the app BELIEVES the PTY holds. Three ways that belief goes wrong with nothing to report the break, and in all three the panel has stopped moving, so the wrapper recomputes the same grid forever and the gate never reopens. _TerminalGridFreeze re-armed its settle countdown on every invocation, and LayoutBuilder re-runs its builder whenever the parent rebuilds, not only when constraints change. Any rebuild cadence faster than the 150ms delay -- a streaming agent, a selection drag, a session-list tick -- cancelled the timer forever, so the grid stayed pinned to whatever it held when the panel last changed size: content clipped at the stale column with dead space beside it, for exactly as long as the agent keeps working. _settlingTo now measures quiet from the last real move, _sameSize replaces exact Size == so sub-pixel flex jitter does not read as movement, and dispose() cancels. sendResize dropped a request whose per-install client id had not resolved while the caller booked it as sent. It now reports whether the frame was QUEUED -- true is not a delivery receipt -- and the three paths that discard an armed frame later (the debounce's driver guard, a cancellation in _handleTerminalSize, disposal) each hand the geometry back through an invalidation. Nothing re-asserted geometry across a reconnect or a same-id respawn. A resize sent into a keyless window vanishes unreported, and a respawned PTY takes terminal-manager's process-wide lastDriverGeometry -- whichever terminal on that bridge resized most recently, not the one this driver sent the dead process. TerminalTab.sizeEpoch is the invalidation edge, the exact parallel of the snapshot-seq cutoff dropped beside it in _rehydrateTerminals, bumped on re-drive and on both respawn signals (terminal:started is not in kCheckoutDurableReplayTypes, so a relay app builds its tabs from the replayed agent:status and sees only that one). The wrapper's per-PTY latches lived in a State that is not per-PTY: only terminal_screen keys it by terminalId, so the pinned pane, the detail view and the setup banner reused the previous terminal's booking across a swap. didUpdateWidget retires them. Also corrects a load-bearing false premise. Two comments justified the freeze by claiming ghostty_vte_flutter does not reflow. It does -- soft-wrapped rows re-join when the grid widens. The freeze's actual rationale survives intact and is the other half: a TUI that wraps its own output writes hard breaks, which reflow never re-joins, so a grid change under an Ink-style redraw leaks stale fragments. terminal_reflow_contract_test.dart pins both halves with margin-filling rows. * Handler: retire notify-only, make the wrap-up durable, and unhook the comments from a missing spec (#73) * Notify-only leaves, and takes the config file's last setting with it `notifyOnly` meant "tell me, never act": every pause escalated without spending a judge call, and a park wake notified instead of typing `continue`. Nothing in the product could turn it on. It was only ever sent as `defaultNotifyOnly`, which is read from handler-config.json — a file the bridge and app only ever read and never write. Hand-editing JSON was the sole path in, so the mode was unreachable by construction. Two engine branches go with it, and `outputSnippet()` behind them, which had no other production caller. `defaultNotifyOnly` was handler-config.json's only setting, so the schema, its v1 migration, the loader, the engine's config cache and `cfg()` go too; config.ts survives on ActivityRecord and appendActivity, which is what a later judge or personality picker actually needs from it. No message type is added or removed, so the checkout-variable sets are untouched. The accepted trade, stated rather than denied: anyone who did hand-edit that file has `notifyOnly: true` persisted in their handler-session record. Their parked sessions will now inject `continue` into a terminal they had opted out of auto-driving, and every pause will spend a judge call. Pre-v1, with no users, that is worth more than a compatibility shim for a mode the UI never exposed. One test is replaced rather than deleted: the notify-only sub-case was the only thing asserting that a standing `guard_blocked` report is not a pending question, and the park-wake gate reads exactly that predicate. It comes back as a park-wake equivalent. * The morning-after summary outlives the session that earned it The wrap-up was composed from three private helpers, spent on one push, and thrown away. The `wrapped_up` activity row kept only the goal, which the app renders nowhere. That left the phone-was-off case with nothing at all: handler-activity.jsonl is write-only, `handler:activity` is not a REPLAY_TYPE, and HandlerState is never persisted — so an app that reconnected after the disarm had no feed to go back to, which is exactly when the summary is read. A WrapUpRecord now persists in handler-wrapups.json and replays on handler:status, which is already a REPLAY_TYPE. One module owns content selection so the push and the stored copy cannot drift: buildWrapUp decides which items and which caps, and the push body and the activity detail are two thin renderings of its output. The undo count is the one thing that must never be frozen. It is an argument to the push renderer, not a field on the record, and the app re-derives it: an undo taken after the wrap-up, or a re-arm retiring the offers, would each make a stored count permanently wrong on a card whose whole job is to be read later. The blocked count and its reasons ARE frozen, deliberately — they die with the session when disarm drops it. Freeze what dies with the session, never freeze what outlives it. Sized for the wire it rides: handler:status is emitted twice per handler event, held by reference in the replay cache, and crosses the relay to a phone, so item text is clipped at 120 chars and five records are kept — a worst case of roughly 22K chars per frame rather than the 190K the uncapped shape allowed. The push loses its "see the activity feed" tail, because that feed is not durable and pointing at it was the bug. Wrap-ups survive a re-arm. Snapshots are retired because each pins a stash, a backup ref and a trash copy and owes a release(); a wrap-up owns no resource, so retiring one would keep that cost and drop its reason — losing the previous session's report is the failure this change exists to fix. * A wrap-up is read hours after the disarm, so it gets a card The bridge replays a WrapUpRecord on handler:status; this is the surface that reads it. The Handler screen gains a pinned Wrap-up section between Sessions and Undo, and a project whose only remaining artifact is a wrap-up no longer renders as "Handler is off" — the morning after, that is the whole screen. The undo count is derived at the call site from the terminal's snapshots and passed to the card, mirroring the bridge's own renderer taking it as an argument. Neither side of the wire is given a field it could freeze the count into, which is the only reason the number is still true when the card is finally read. Sans, not mono: a wrap-up is the user's own prose plus chrome labels, never a path or a command. The outcome labels are a local switch rather than a reuse of the activity-row labels — those are keyed on activity decisions (`item_done`), not wire outcome statuses (`done`), and bridging them would be a second hand-mirror to keep in lockstep for four words. * Comments stop citing a spec nobody can open Roughly 180 comments pointed at numbered sections of a handler spec — `§4.3`, `spec §5.2` — that is not in this repository and is not in its history. A reader who followed one found nothing, and the tag was doing the work a sentence should have been doing. Where the prose already carried the reason, the tag is simply gone. Where the section number WAS the justification, the reason is written in from the code that enforces it and names the symbol a reader can grep: `§5.4` becomes instruction-scoped authorization and the file that implements it, `§5.3` becomes the HARD floor tier, `§2.2` becomes the terminal states. The references that resolve are untouched: bridge/src/e2e/ and packages/ cite docs/protocol/e2e-handshake.md by named section and that file exists, and two more cite RFCs. A path plus a named section is a working pointer; a bare number pointing at nothing is not, and that is the whole distinction being applied here. No spec document was written to make the numbers resolve. Reconstructing one from the code would invent a source of truth that never existed and leave 180 comments citing a document written after them. * Test names say what they check, not which section asked for it The same dangling references ran through test names — `describe("§2.2 allTerminal is the wrap-up predicate")`, `group('quick choices (§4.6)')`. A test name is read in failure output, where a section number from a missing document is worth less than nothing: it names an authority the reader cannot consult instead of the behaviour that broke. Renamed to describe the behaviour, and the comments inside these files got the same treatment as the source. Pass counts are unchanged — 3159 bridge, 3087 app.
bharathm03
added a commit
that referenced
this pull request
Sep 2, 2026
* Update dart_terminal viewport fix (#65) Pin the terminal packages to the squash merge of antgrid-ai/dart_terminal#10 so Antgrid receives the origin-safe scrollbar synchronization and selection auto-scroll fix. * Handler: a status frame answers for exactly one append (#64) * A frame that answers one sentence is never spent on the next A partial cap hit appends, records instruction_dropped AND emits a status snapshot whose backlog that same sentence just moved. Only the amendment path was credited for that, so the cap hit's frame was left to answer for the next sentence in the queue — retiring its row and lifting the edit lock while its extraction was still running. A survivor is now always credited; crediting a frame the bridge never sends costs one re-baseline, which is what the survivor was doing anyway. Alongside it, four places where one rule had two homes: oneLine is defined once in the import-free leaf and re-exported, hostsIn is built on destinationsIn so "the subset of" is true by construction, clipQuote escapes through previewForUser like every other user-facing preview, and a shared clip() stops a cap landing between a surrogate pair. The drawer's waits-on lines resolve against one map per build rather than re-walking the backlog per link. * The goal seeded at arm answers for its own append A goal is extracted on the same per-terminal chain instructions queue on, and lands ahead of them. Its items moved the backlog count that was the only evidence a sentence had, so a preset tapped while the goal was still running was retired by the goal's own append — taking the "sending" row away, lifting the debounce, and lifting the drawer's edit lock while the preset's extraction had not started. The next wholesale edit then went out built from a list missing the items about to arrive. One client reaches this: arm from the new-session prompt, tap a chip before the goal comes back. The mark is set on exactly the condition the bridge queues that pass on — a goal with words in it and no backlog beside it — spent by the first frame that actually moved, and dropped once the backlog is non-empty, since a goal is only ever extracted into an empty one and a mark left standing would swallow the frame the user's own sentence raised. * feat: start an isolated session's agent alongside worktree.setup (#66) * fix(app): show the provisioning run instead of calling a queued session stopped While worktree.setup runs the bridge holds the session's start, so the entry reports running:false with setup.pendingStart set. TerminalScreen branched on running alone and rendered "Session stopped" over a Start button whose press only re-entered the same gate — a dead control directly under a banner saying the workspace was being prepared. The pane now branches on sessionStartQueued to a provisioning state whose body is the setup transcript itself: during the run that PTY is the only live output the session has, and it was collapsed behind a chevron above an empty pane. It carries the two verbs that end the wait — Start agent now (skip) and Cancel setup, the latter a bridge verb no surface had offered since it shipped. Renames the banner's Skip to Start agent now: nothing about the run is skipped, the queued agent is released and the install keeps going. The banner drops its tail line and log disclosure while a start is queued so the transcript is not mounted twice, both derived from the same wire field. * feat(bridge): let a project launch its agent alongside worktree.setup Adds worktree.setup.startAgent: afterSetup | immediate. The default keeps today's behaviour — the session:start is queued and fired when the run settles — while immediate lets the agent come up with the first step, for a project whose setup is a cheap .env copy rather than a cold install. The mechanism is one seeded field: beginSetup births the run with gateReleased true, which is the same state a Skip produces, so setupGate declines to report it and start() falls through to the spawn with no branch of its own. Skip stays idempotent, cancel and rerun are untouched, and firePendingStart finds nothing queued. checkoutDeclaresSetup widens to checkoutSetupPolicy, returning declares plus startAgent; an unreadable config now fails closed on both axes rather than one. The rerun path re-reads the policy instead of remembering the create-time answer — a rerun is exactly when the checkout's branch has changed it. The services: deferral stays tied to declares alone: bun run dev against an unprovisioned node_modules fails with nobody watching, unlike an agent. * feat(app): warn when the agent is live in an unprovisioned tree Under startAgent: immediate — or after a hand-pressed release — the agent is running in a checkout that has no node_modules yet, which is a different claim from a neutral progress line. The banner is the only surface that can make it, so it takes the warning tone, says 'Workspace still installing' rather than promising a wait, and offers 'Cancel setup' in place of a release that has nothing left to release. Derived from the two live facts (setup running AND session running) rather than a mode flag, so a config-set immediate and a hand-pressed Start agent now reach the same warning. * feat: make the provisioning wait legible "2 of 5" is actively misleading on a real setup block: step 1 is a 10ms copy: and steps 2-5 are the minutes. The pane now carries the ledger — done, current, still to come — and the banner an elapsed readout beside the rule, which is what separates a slow step from a hung one when a bun install prints nothing for four minutes. The step names are the one new wire field: CheckoutSetupProgress carries them on every report of a run rather than once, SetupRuntime retains them like terminalId, and they are optional in both directions so an older app ignores the key and a state recovered from disk (which knows how many steps ran but not what they were called) renders no ledger at all rather than a column of blanks. The elapsed reading comes off the bridge's clock, which for a remote machine is not ours; a negative result is the one shape of skew we can detect and it is answered by saying nothing. * chore: start this repo's isolated agents alongside setup Five steps, minutes on a cold worktree, and the agent is useful for most of that — reading, planning and searching all work in a tree that is only checked out. Revert this one line to put the wait back. * fix: close the review's findings on the setup-start work The load-bearing one is a bug: a rerun under startAgent: immediate re-arms the previous run's prompt AND opens the gate, and nothing fires a start behind an open gate — firePendingStart runs only when a run settles. The policy was therefore silently ignored on the one path that queues a start of its own, leaving the user to press Start agent now by hand on a project configured never to wait. The rerun test that was supposed to cover this passed for the wrong reason: settleSetup awaits startDeferredServices, so the synchronous stop() beat firePendingStart and lastQueuedPrompt was never banked — the rerun took the no-requeue path. It now waits for the queued start, and fails without the fix. App side: a chat session mounts AgentTranscriptView where the provisioning pane would be, so standing the banner's log and tail down on a queued start left a four-minute install with no output anywhere; the suppression now keys on the pane actually being there. The banner also stood down its action, since two Start agent now buttons 100px apart race for a run only one can end. The pane is keyed by session so an in-flight verb and its refusal cannot land on whichever session is on screen when the reply arrives, a null registration is named rather than dropped, and an expansion the chevron no longer offers is cleared instead of masked — masking alone unfolded the log by itself the moment the gate released. Also folded two duplicated helpers back into their existing versions: listEquals for the step-name comparison, and the transcript's formatDuration so the two live elapsed readouts on one screen spell the same seconds the same way. * A dropped instruction owes the app the frame its record spends (#68) Regression from #64. Retiring a sentence off an activity record now always credits the terminal's next status frame, but two bridge paths recorded an instruction row and returned without emitting one — an amend-only drop, and a cap hit with no room at all. The credit was then spent by the NEXT sentence's own append, so that sentence's row stood forever and held the backlog edit lock, which under a full backlog is the only way to free room. Fixed on the bridge, because no app-side rule can work: nothing in an instruction_dropped record says whether a frame is coming. Every path that records an instruction row now emits a snapshot straight after it, making the app's blanket rule a real invariant. An unchanged snapshot answers for nothing and costs one re-baseline. The arm-time goal mark also now mirrors the bridge's goalChanged gate, so re-arming with an unchanged goal no longer sets a mark nothing will satisfy. A goal the bridge rehydrates from its own disk record still cannot be predicted from the app. * Handler: confirm the undo that leaves this machine, band escalations, answer the judge question before arming (#69) * The one undo that leaves this machine asks first Three of the four snapshot actions undo locally; undoing a force push writes to a shared remote. The offer is a scrolling list row whose entire body is the tap target, newest first, so the row most likely to sit under a thumb was also the one that could overwrite a ref for everyone on it. Confirmed for force_push alone. The other three keep the one-tap prevention §5.2 buys back, and the dialog promises no recovery: the bridge pins the remote tip before overwriting it only when the ref still exists there. * An escalation that stopped the agent sorts above one that merely waited urgency reached exactly one consumer, the OS notification title, and that path returns early while you are viewing the session it names. Inside the app the field was invisible: high and normal rendered identically, and oldest-first sorting filed a fresh urgent row last. high is not judge opinion. The engine mints it with no judge call at all for a blocking prompt, so it means the agent is stopped right now. Banded ordering with age deciding within a band, applied on the live push too, and marked in the meta column the three escalation row shapes share. * Escalate-only is answerable before arming, not after walking away The catalog already carried judgeCapable and the app already resolved the agent the way the bridge does, so the pre-arm half of the coverage answer was one field away. Until now it surfaced only as an ESCALATE ONLY chip, found on returning to a session that had woken you for everything. The prediction is exact rather than approximate because nothing writes a per-session judge override yet: observabilityFor falls back to the session's own tool, which is what the catalog describes. A judge picker inherits the job of keeping that true. Withheld from the unwatchable arm, which already carries the stronger fact. * Corrections to the three fixes above latestEscalationId read escalations.last, which the new banding turned into the newest NORMAL escalation — never an urgent one, i.e. never the row a caller asking for the latest wants. Folded on at instead. No live caller today, so this was a trap rather than a bug. The undo tap started async work from a void callback with a bare unawaited, which app/CLAUDE.md forbids outright: a throw past the dialog reaches PlatformDispatcher.onError as a FATAL with no in-app frames. Uses detached now. The urgency test is a shared escalationMeta closure rather than three hand-written copies, which is what the adjacent comment already claimed. compareEscalations' doc asserted high is engine-minted only. It is not: escalate passes the judge's own notify.urgency through, so a judge-authored high sorts into the same band. The band is still right; the claim was not. Two bridge comments calling backlog.ts import-free were wrong the same way — it imports zod and ./evidence; what it actually has is a position below every consumer. * The shield answers the judge question every time it is asked The escalate-only caveat added to the arm explainer almost never rendered: the explainer is gated on FirstRunState.handlerArmedOnce, a once-EVER latch, while coverage is per-agent. A user whose first arm was a judge-capable agent never sees that dialog again and would meet an escalate-only one with no warning at all. The shield tooltip is the pre-arm surface that answers every time, and it already carried the observability half. handlerShieldTooltip is top-level for the reason handlerArmExplainerBody is — the precedence is testable without pumping the panel — and keeps the explainer's order: unwatchable outranks escalate-only, since a session reporting nothing makes its judge moot. * feat(app): markdown document viewer with heading outline, link routing, and mono inline code The file viewer rendered markdown through markdown_widget's defaults — GitHub's light-theme tokens on a dark ground — with no reading measure, no way to follow a link, and no sense of a document's structure. Adds three files. markdown_document_config.dart is the whole-document config: a document-scale heading ramp pinned at all six levels, dark-appropriate blockquote/hr/table tokens, mono tables that scroll internally rather than overrun the measure, list markers whose shape encodes nesting depth, task boxes drawn with AbIcons, a copy button on every fence, and a chip for a repo-relative image the viewer can open in its own image view. markdown_outline.dart is the heading rail: MarkdownWidget publishes its heading list and scroll index only into its TocController, so MarkdownTocController subclasses it to get both out, and the rail is built from AbListRow instead of the package's Material ListTile. markdown_link_target.dart classifies an href so a relative repo link opens in the viewer, a #anchor jumps in-document, and only http(s)/mailto leave the app. Also fixes inline code across the agent transcript. CodeNode.style resolves as codeConfig.style.merge(parentStyle), and merge lets the argument win every non-null field, so the paragraph's sans family overwrote the configured mono one and CodeConfig was inert — every backticked identifier, path and flag rendered byte-identical to the prose around it. markdownAntgridGenerator re-asserts the family after that merge, family only, and transcript/markdown_body.dart renders through it too. * fix(app): bump webview_all to 1.4.1 for the Windows exit crash (#70) 1.4.1 removes the `SetUp(messenger_, nullptr)` call from `~WindowsHostApi`. That call ran from a plugin registrar destruction callback, which FlutterWindowsEngine fires from Stop() after its own destructor has already nulled the messenger's engine pointer — so it dereferenced null inside FlutterDesktopMessengerSetCallback, whose only guard is an FML_DCHECK that is compiled out in release. The app never pinned the platform package directly, so this is a lock-only change: `webview_all: ^1.3.5` in pubspec.yaml already admits 1.4.1, and no dependency_override or fork was ever added. Verified the fix is in the bytes we resolve: the published archive's sha256 (d9b81f1…) matches both pub.dev and this lockfile, and that archive's `~WindowsHostApi` no longer makes the call. Upstream: abandoft/webview_all#37, fixed by abandoft/webview_all#38. Not yet confirmed at runtime in Antgrid. Four local configurations (profile/release × with/without a host, three launch-and-close runs each) exit cleanly even on the buggy 1.3.10, so no loose build reproduces the fault and a clean run proves nothing. The signature is an access violation escaping a window-proc callback (0xC0000005 paired with 0xC000041D at the same offset), which is plausibly swallowed outside the packaged app — so confirmation has to come from an MSIX build. Refs #63, whose third close condition (no Application Error event on exit) is still open. Claude-Session: https://claude.ai/code/session_01Y8Fukr3ARpsyhoxrSMcPcm * Handler: submit the line it types, and ask the agent before answering for it (#71) Two reports from a live two-session run, both confirmed against the logs. The injected reply never sent. A TUI tokenizes a PTY read as a whole, and Claude Code emits a control character as its own key event only while the read is under 64 characters — so `text\r` in one write inserted a literal newline into the composer and waited for a human Enter. Every submit now writes the line, waits out a gap, then writes the CR alone, through a per-terminal queue that keeps any other writer from joining that read. The queue is a synchronous pass-through whenever no submit is in flight, so nothing else on the terminal pays for it. A bare slash verb is padded: splitting the CR would otherwise leave the suggestion list armed, and the key that used to submit would accept a completion instead. Handler answered for the agent. It holds less context and fewer tools than the model it supervises, so a confident guess reads as fact and costs the agent a correction it has no reason to make. It asks now, and decides from what comes back. Seven further findings from the log dig, each adversarially validated: the runaway guard reset on keystrokes that submitted nothing; the judge's timeout was silent on two of the three legs that spend the budget; the activity feed reported a blocked action with prose about the pause rather than the text a guard refused; the destructive floor missed several outward-moving operations, and the wrap-up push buried an expiring undo offer behind an unbounded summary. Floor patterns are one operation each. Section 5.4 keys an authorization lift on the pattern source, so an alternation over two operations lets a lift on either grant both. * A terminal resize the PTY never got is one the app must keep offering (#72) The driver re-sends terminal:resize only when its computed grid differs from _lastSentCols/_lastSentRows -- what the app BELIEVES the PTY holds. Three ways that belief goes wrong with nothing to report the break, and in all three the panel has stopped moving, so the wrapper recomputes the same grid forever and the gate never reopens. _TerminalGridFreeze re-armed its settle countdown on every invocation, and LayoutBuilder re-runs its builder whenever the parent rebuilds, not only when constraints change. Any rebuild cadence faster than the 150ms delay -- a streaming agent, a selection drag, a session-list tick -- cancelled the timer forever, so the grid stayed pinned to whatever it held when the panel last changed size: content clipped at the stale column with dead space beside it, for exactly as long as the agent keeps working. _settlingTo now measures quiet from the last real move, _sameSize replaces exact Size == so sub-pixel flex jitter does not read as movement, and dispose() cancels. sendResize dropped a request whose per-install client id had not resolved while the caller booked it as sent. It now reports whether the frame was QUEUED -- true is not a delivery receipt -- and the three paths that discard an armed frame later (the debounce's driver guard, a cancellation in _handleTerminalSize, disposal) each hand the geometry back through an invalidation. Nothing re-asserted geometry across a reconnect or a same-id respawn. A resize sent into a keyless window vanishes unreported, and a respawned PTY takes terminal-manager's process-wide lastDriverGeometry -- whichever terminal on that bridge resized most recently, not the one this driver sent the dead process. TerminalTab.sizeEpoch is the invalidation edge, the exact parallel of the snapshot-seq cutoff dropped beside it in _rehydrateTerminals, bumped on re-drive and on both respawn signals (terminal:started is not in kCheckoutDurableReplayTypes, so a relay app builds its tabs from the replayed agent:status and sees only that one). The wrapper's per-PTY latches lived in a State that is not per-PTY: only terminal_screen keys it by terminalId, so the pinned pane, the detail view and the setup banner reused the previous terminal's booking across a swap. didUpdateWidget retires them. Also corrects a load-bearing false premise. Two comments justified the freeze by claiming ghostty_vte_flutter does not reflow. It does -- soft-wrapped rows re-join when the grid widens. The freeze's actual rationale survives intact and is the other half: a TUI that wraps its own output writes hard breaks, which reflow never re-joins, so a grid change under an Ink-style redraw leaks stale fragments. terminal_reflow_contract_test.dart pins both halves with margin-filling rows. * Handler: retire notify-only, make the wrap-up durable, and unhook the comments from a missing spec (#73) * Notify-only leaves, and takes the config file's last setting with it `notifyOnly` meant "tell me, never act": every pause escalated without spending a judge call, and a park wake notified instead of typing `continue`. Nothing in the product could turn it on. It was only ever sent as `defaultNotifyOnly`, which is read from handler-config.json — a file the bridge and app only ever read and never write. Hand-editing JSON was the sole path in, so the mode was unreachable by construction. Two engine branches go with it, and `outputSnippet()` behind them, which had no other production caller. `defaultNotifyOnly` was handler-config.json's only setting, so the schema, its v1 migration, the loader, the engine's config cache and `cfg()` go too; config.ts survives on ActivityRecord and appendActivity, which is what a later judge or personality picker actually needs from it. No message type is added or removed, so the checkout-variable sets are untouched. The accepted trade, stated rather than denied: anyone who did hand-edit that file has `notifyOnly: true` persisted in their handler-session record. Their parked sessions will now inject `continue` into a terminal they had opted out of auto-driving, and every pause will spend a judge call. Pre-v1, with no users, that is worth more than a compatibility shim for a mode the UI never exposed. One test is replaced rather than deleted: the notify-only sub-case was the only thing asserting that a standing `guard_blocked` report is not a pending question, and the park-wake gate reads exactly that predicate. It comes back as a park-wake equivalent. * The morning-after summary outlives the session that earned it The wrap-up was composed from three private helpers, spent on one push, and thrown away. The `wrapped_up` activity row kept only the goal, which the app renders nowhere. That left the phone-was-off case with nothing at all: handler-activity.jsonl is write-only, `handler:activity` is not a REPLAY_TYPE, and HandlerState is never persisted — so an app that reconnected after the disarm had no feed to go back to, which is exactly when the summary is read. A WrapUpRecord now persists in handler-wrapups.json and replays on handler:status, which is already a REPLAY_TYPE. One module owns content selection so the push and the stored copy cannot drift: buildWrapUp decides which items and which caps, and the push body and the activity detail are two thin renderings of its output. The undo count is the one thing that must never be frozen. It is an argument to the push renderer, not a field on the record, and the app re-derives it: an undo taken after the wrap-up, or a re-arm retiring the offers, would each make a stored count permanently wrong on a card whose whole job is to be read later. The blocked count and its reasons ARE frozen, deliberately — they die with the session when disarm drops it. Freeze what dies with the session, never freeze what outlives it. Sized for the wire it rides: handler:status is emitted twice per handler event, held by reference in the replay cache, and crosses the relay to a phone, so item text is clipped at 120 chars and five records are kept — a worst case of roughly 22K chars per frame rather than the 190K the uncapped shape allowed. The push loses its "see the activity feed" tail, because that feed is not durable and pointing at it was the bug. Wrap-ups survive a re-arm. Snapshots are retired because each pins a stash, a backup ref and a trash copy and owes a release(); a wrap-up owns no resource, so retiring one would keep that cost and drop its reason — losing the previous session's report is the failure this change exists to fix. * A wrap-up is read hours after the disarm, so it gets a card The bridge replays a WrapUpRecord on handler:status; this is the surface that reads it. The Handler screen gains a pinned Wrap-up section between Sessions and Undo, and a project whose only remaining artifact is a wrap-up no longer renders as "Handler is off" — the morning after, that is the whole screen. The undo count is derived at the call site from the terminal's snapshots and passed to the card, mirroring the bridge's own renderer taking it as an argument. Neither side of the wire is given a field it could freeze the count into, which is the only reason the number is still true when the card is finally read. Sans, not mono: a wrap-up is the user's own prose plus chrome labels, never a path or a command. The outcome labels are a local switch rather than a reuse of the activity-row labels — those are keyed on activity decisions (`item_done`), not wire outcome statuses (`done`), and bridging them would be a second hand-mirror to keep in lockstep for four words. * Comments stop citing a spec nobody can open Roughly 180 comments pointed at numbered sections of a handler spec — `§4.3`, `spec §5.2` — that is not in this repository and is not in its history. A reader who followed one found nothing, and the tag was doing the work a sentence should have been doing. Where the prose already carried the reason, the tag is simply gone. Where the section number WAS the justification, the reason is written in from the code that enforces it and names the symbol a reader can grep: `§5.4` becomes instruction-scoped authorization and the file that implements it, `§5.3` becomes the HARD floor tier, `§2.2` becomes the terminal states. The references that resolve are untouched: bridge/src/e2e/ and packages/ cite docs/protocol/e2e-handshake.md by named section and that file exists, and two more cite RFCs. A path plus a named section is a working pointer; a bare number pointing at nothing is not, and that is the whole distinction being applied here. No spec document was written to make the numbers resolve. Reconstructing one from the code would invent a source of truth that never existed and leave 180 comments citing a document written after them. * Test names say what they check, not which section asked for it The same dangling references ran through test names — `describe("§2.2 allTerminal is the wrap-up predicate")`, `group('quick choices (§4.6)')`. A test name is read in failure output, where a section number from a missing document is worth less than nothing: it names an authority the reader cannot consult instead of the behaviour that broke. Renamed to describe the behaviour, and the comments inside these files got the same treatment as the source. Pass counts are unchanged — 3159 bridge, 3087 app.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Handler was armed on two live sessions. Two symptoms were reported: its
auto-replies appeared in the agent's composer but were never sent, and it wrote
prescriptive how-to recipes instead of asking the agent that holds the session.
Both reproduce from the logs, and digging into them surfaced five more.
Every finding here was put to an adversarial skeptic before it was fixed. Two of
the original seven were partly refuted and are implemented in a different shape
than first proposed; that is called out below.
The reply never submitted
createPtyAdapter.injectReplywas one PTY write oftext + "\r". A coding-agentTUI tokenizes a read as a whole — Claude Code emits a control character as its own
key event only while the entire read is under 64 characters — so at or above that
the CR is absorbed into the surrounding text run and inserted as literal text. The
prompt is typed and never sent.
The live log matches exactly: of seven injected replies (18, 294, 449, 497, 552,
782, 1014 chars) only the 18-character one could have submitted. Nothing detects
the failure — the engine records
handle, banks its transitions, counts therunaway guard and rests at
watching, while the agent emits no turn-end and thesession goes quiet with the text stranded.
Submitting now goes through
TerminalSession.submit: the line, a gap, then the CRin a read of its own, serialized per terminal so a keystroke cannot land inside the
injected line. The seam sits below both the handler adapter and agent-core's
terminal:input, so the app's escalation chips (up to 400 characters) are coveredby the same mechanism rather than a second one — a chip previously typed without
submitting while the card latched disabled and both sides dropped the row.
A bare slash verb is padded with one trailing space. With the CR in its own read,
/compactreaches Claude Code's Enter handler while the suggestion list is openand selection has settled on the exact match, which routes Enter to accept rather
than submit; any slash line containing a space clears the list first. This is a
regression introduced by the split itself, found by the audit, not a pre-existing
bug.
The judge had no way to ask
buildDecidePromptoffered two moves: answer with high confidence, or escalate tothe human. Nothing let it ask the AGENT — which holds the live session, the working
tree and write access, where the judge reads a bounded excerpt. The result was
294–1014 character command recipes, including a commit message the agent then used
verbatim.
The prompt gains the ask move and an altitude rule (the Handler decides what should
happen next and why; the agent decides how), placed immediately after the
escalate rule so a question cannot divert what belongs to the human. The
decisionenum is unchanged — asking is ahandlewhose reply is a question — anda test pins that a fourth enum value stays rejected.
Runaway guard and escalation gate
onUserReplyreset the runaway guard on every inboundterminal:inputbyte,before any test. Once an agent enables mouse tracking every pointer event is one
of those frames, so moving the mouse across the terminal restored the auto-reply
budget tens of times a second; one ordinary typed character did it too. The guard's
own header says it resets "on a human reply".
The same method's
/[\r\n]/test also matched alt+enter (\x1b\r) and a paste'sinterior newlines — neither submits anything — and retired pending escalations
while the agent stayed blocked. Escalations never supersede, so a row cleared that
way is not re-raised.
Both collapse into one
isSubmitKeystrokegate, kept above the session lookup soguard state is still reclaimed for a terminal with no live session. The three input
predicates move to
keystrokes.ts, a leaf module both agent-core and the handlercan import without a cycle.
Shape refusals (partly refuted)
The original claim was that the
reply/actionXOR left the judge no way to run acommand with instructions. That is wrong — the XOR never fired in the live log, and
it stays. The real defect is next to it:
replyShapeflattenedreplybut neveractionText, so a newline in a command's argument tail was refused by thecontrol-char rule; and because
written = actionText || reply, the refusal wasreported as "reply contains control characters". That reason is fed verbatim to the
one retry, teaching the judge to edit the field it got right.
Destructive floor
The floor had no
ghverbs at all. In the live run the Handler drovegh pr merge --delete-branchintodevelopmenttwice with no warning and nosnapshot. Added at the DESTRUCTIVE tier:
gh pr merge|close,gh release|repo delete, forced branch delete,git tag -d,npm publish.Advisory, never HARD — a HARD entry is liftable by nothing, and merging the pull
request is routinely the backlog's whole point. Nothing snapshots these, so they
now report that no undo exists rather than passing in silence, and the alias table
learns their prose so an authorized merge stops recurring as a reason to escalate.
Blocked-action push (partly refuted)
The original claim — that
handler-activity.jsonlis unread and the push points ata feed nothing can replay — is wrong: the activity feed is a live app screen fed by
the
handler:activitypush. The real gap is narrower.handler:statusreplayssessions, escalations and snapshots but never activity rows, and
blockedNoterides an OS push, the one channel that reaches a phone whose app was not running
when those rows went out. So "N action(s) Handler could not take — see the activity
feed" could land on an empty feed. The push now says what was refused.
The claim that the synchronous append is a defect was dropped: the whole handler
persistence layer is sync, and every
record()call site sits beside a strictlylarger sync write.
Verification
bun run --filter antgrid-bridge test— 3198 pass, 0 fail (baseline 3148, so +50tests).
tsc --noEmitclean. No files underapp/change, so the Flutter gatesare untouched.
Not yet verified against a live Claude Code PTY. The 20ms gap and the bare-verb
padding are reasoned from the 2.1.252 binary's tokenizer and covered by unit tests,
but not measured end-to-end. The underlying bug is intermittent — a read the OS
happens to split short submits fine today — so this is worth exercising at 64,
~500 and ~4000 characters plus a bare
/compact, on Windows specifically, whereConPTY is the coalescing layer.